S15-05 算法-树结构
[TOC]
树结构 Tree
树
什么是树
真实的树: 相信每个人对现实生活中的树都会非常熟悉。
树的特点:
- 树通常有一个根。连接着根的是树干。
- 树干到上面之后会进行分叉成树枝,树枝还会分叉成更小的树枝。
- 在树枝的最后是叶子。
树的抽象: 专家们对树的结构进行了抽象,发现树可以模拟生活中的很多场景。
模拟树结构
公司组织架构:
红楼梦家谱:
DOM Tree:
树结构的抽象
我们再将里面的数据移除,仅仅抽象出来结构,那么就是我们要学习的树结构
树的优点
我们之前已经学习了多种数据结构来保存数据,为什么要使用树结构来保存数据呢?
树结构和数组/链表/哈希表的对比有什么优点呢? 树的优点
数组:
优点:
- 数组的主要优点是根据下标值访问效率会很高。
- 但是如果我们希望根据元素来查找对应的位置呢?
- 比较好的方式是先对数组进行排序,再进行二分查找。
缺点:
- 需要先对数组进行排序,生成有序数组,才能提高查找效率。
- 另外数组在插入和删除数据时,需要有大量的位移操作(插入到首位或者中间位置的时候),效率很低。
链表:
优点:
- 链表的插入和删除操作效率很高。
缺点:
- 查找效率很低,需要从头开始依次访问链表中的每个数据项,直到找到。
- 而且即使插入和删除操作效率很高,但是如果要插入和删除中间位置的数据,还是需要从头先找到对应的数据。
哈希表:
优点:
- 我们学过哈希表后,已经发现了哈希表的插入、查询、删除时效率都是非常高的。
缺点:
- 空间利用率不高,底层使用的是数组,并且某些单元是没有被利用的。
- 哈希表中的元素是无序的,不能按照固定的顺序来遍历哈希表中的元素。
- 不能快速的找出哈希表中的最大值或者最小值这些特殊的值。
树结构:
- 我们不能说树结构比其他结构都要好,因为每种数据结构都有自己特定的应用场景。
- 但是树确实也综合了上面的数据结构的优点(当然优点不足于盖过其他数据结构,比如效率一般情况下没有哈希表高)。
- 并且也弥补了上面数据结构的缺点。
而且为了模拟某些场景,我们使用树结构会更加方便。
- 因为树结构是非线性的,可以表示一对多的关系。
- 比如文件的目录结构。
树的术语
在描述树的各个部分的时候有很多术语。为了让介绍的内容更容易理解,需要知道一些树的术语。不过大部分术语都与真实世界的树相关,或者和家庭关系相关(如父节点和子节点),所以它们比较容易理解。
树的术语:
- 树(Tree):n(n≥0)个节点构成的有限集合。
当n=0时,称为空树;
对于任一棵非空树(n> 0),它具备以下性质:
- 树中有一个称为“根(Root)”的特殊节点,用 r 表示;
- 其余节点可分为m(m>0)个互不相交的有限集
T₁, T₂, ..., Tₘ
,其中每个集合本身又是一棵树,称为原来树的“子树(SubTree)”
- 根节点(root node):位于二叉树顶层的节点,没有父节点。
- 叶节点(leaf node):没有子节点(度为0)的节点,其两个指针均指向
None
。 - 子节点(Child):若A节点是B节点的父节点,则称B节点是A节点的子节点;子节点也称孩子节点。
- 父节点(Parent):有子树的节点是其子树的根节点的父节点。
- 兄弟节点(Sibling):具有同一父节点的各节点彼此是兄弟节点。
- 节点所在的层(level):从顶至底递增,根节点所在层为 1 。
- 节点的度(degree):节点的子节点的数量。在二叉树中,度的取值范围是 0、1、2 。
- 节点的深度(depth):从根节点到该节点所经过的边的数量。
- 节点的高度(height):从距离该节点最远的叶节点到该节点所经过的边的数量。
- 树的高度(height):从根节点到最远叶节点所经过的边的数量。
- 边(edge):连接两个节点的线段,即节点引用(指针)。
- 路径:从节点n₁到nₖ的路径为一个节点序列
n₁,n₂,… ,nₖ
。nᵢ是 nᵢ₊₁的父节点。 - 路径长度:路径所包含边的个数为路径的长度。
树的表示方式
普通表示方式:
const ANode = { value: 'A', leftNode: BNode, centerNode: CNode, rightNode: DNode }
儿子-兄弟表示法:
const ANode = { value: 'A', sunNode: BNode, siblingNode: null }
const BNode = { value: 'B', sunNode: ENode, siblingNode: CNode }
const CNode = { value: 'C', sunNode: GNode, siblingNode: DNode }
const DNode = { value: 'D', sunNode: HNode, siblingNode: null }
儿子-兄弟表示法旋转:
const ANode = { value: 'A', sunNode: BNode, siblingNode: null }
const BNode = { value: 'B', sunNode: ENode, siblingNode: CNode }
const CNode = { value: 'C', sunNode: GNode, siblingNode: DNode }
你发现上面规律了吗?
- 其实所有的树本质上都可以使用二叉树模拟出来。
- 所以在学习树的过程中,二叉树非常重要。
二叉树
概述
概念
如果树中每个节点最多只能有两个子节点,这样的树就成为"二叉树"。
前面我们已经提过二叉树的重要性,不仅仅是因为简单,也因为几乎上所有的树都可以表示成二叉树的形式。
二叉树的定义:
- 二叉树可以为空,也就是没有节点。
- 若不为空,则它是由根节点和称为其左子树TL和右子树TR的两个不相交的二叉树组成。
二叉树有五种形态:
二叉树的特性
二叉树有几个比较重要的特性,在笔试题中比较常见:
- 一颗二叉树第i层的最大节点数为:
2ⁱ⁻¹
,i >= 1; - 深度为k的二叉树有最大节点总数为:
2ᵏ-1
,k >= 1; - 对任何非空二叉树T,若n₀表示叶节点的个数、n₂是度为2的非叶节点个数,那么两者满足关系
n₀ = n₂ + 1
。
二叉树的分类
- 完美二叉树:每个节点都有左右两个子节点,且所有叶子节点都在同一层。
- 完全二叉树:除了最后一层外,其他层的节点都满;最后一层的节点集中在左侧。
- 平衡二叉树:左右子树的高度差不超过1。
- 二叉搜索树(BST):对于树中的每个节点,左子树的值小于该节点,右子树的值大于该节点。
完美二叉树
完美二叉树(Perfect Binary Tree,Full Binary Tree,满二叉树):在二叉树中,除了最下一层的叶节点外,每层节点都有2个子节点,就构成了满二叉树。
完全二叉树
完全二叉树(Complete Binary Tree):除二叉树最后一层外,其他各层的节点数都达到最大个数。且最后一层从左向右的叶节点连续存在,只缺右侧若干节点。完美二叉树是特殊的完全二叉树。
下面不是完全二叉树,因为D节点还没有右节点,但是E节点就有了左右节点。
二叉树存储
二叉树的存储常见的方式是数组和链表。
数组存储
完全二叉树:按从上至下、从左到右顺序存储
非完全二叉树:非完全二叉树要补全成完全二叉树才可以按照上面的方案存储。但是会造成很大的空间浪费。
链表存储
二叉树最常见的方式还是使用链表存储。
思路:每个节点封装成一个Node,Node中包含存储的数据,左节点的引用,右节点的引用。
二叉搜索树
概述
概念
二叉搜索树(BST,Binary Search Tree,二叉排序树,二叉查找树):满足以下条件:
- 1、对于根节点,左子树中所有节点的值 < 根节点的值 < 右子树中所有节点的值。
- 2、任意节点的左、右子树也是二叉搜索树,即同样满足条件1。
二叉搜索树的上述性质使得它的查找效率非常高。
查找、插入
示例:在二叉搜索树中查找值为10的节点
上述方式就是二分查找的思想
- 查找所需的最大次数等于二叉搜索树的深度;
- 插入节点时,也利用类似的方法,一层层比较大小,找到新节点合适的位置。
二叉搜索树实现
API
二叉搜索树的常见操作:
插入操作:
- insert():
(value)
,向树中插入一个新的数据。
遍历操作:
inOrderTraverse():
()
,通过中序遍历方式遍历所有节点。preOrderTraverse():
()
,通过先序遍历方式遍历所有节点。postOrderTraverse():
()
,通过后序遍历方式遍历所有节点。levelOrderTraverse():
()
,通过层序遍历方式遍历所有节点。
查找操作:
getMinValue():
()
,返回树中最小的值。getMaxValue():
()
,返回树中最大的值。search():
(value)
,在树中查找一个数据,如果节点存在,则返回true;如果不存在,则返回false。
删除操作:
- remove():
(value)
,从树中移除某个数据。
BSTree 类
我们像封装其他数据结构一样,先来封装一个BSTree的类
图解:
代码解析:
- 封装BSTree类:
- 对于BSTree来说,只需要保存根节点即可,因为其他节点都可以通过根节点找到。
- 封装Node类:还需要封装一个用于保存每一个节点的Node类。
- 该类包含三个属性:节点对应的value,指向的左子树left,指向的右子树right。
代码实现:
1、封装Node类
2、封装TreeNode类,它继承Node类
3、封装BSTree类
insert() 插入
insert():(value)
,向树中插入一个新的数据。
图解:
代码实现:
1、封装insert()方法
2、封装insertNode()方法
3、测试:调用insert()方法。同时安装 hy-algokit 插件,打印生成的树结构
▸ 优化:在类的内部封装打印方法print()
上述的方法中需要在类的外部访问root
,不太安全。
遍历二叉搜索树
前面,我们向树中插入了很多的数据,为了能看到测试结果。我们先来学习一下树的遍历。
这里我们学习的树的遍历,针对所有的二叉树都是适用的,不仅仅是二叉搜索树。
树的遍历:
- 遍历一棵树是指访问树的每个节点(也可以对每个节点进行某些操作,我们这里就是简单的打印)
- 但是树和线性结构不太一样,线性结构我们通常按照从前到后的顺序遍历,但是树呢?
- 应该从树的顶端还是底端开始呢? 从左开始还是从右开始呢?
二叉树常见的遍历方式有四种:
- 先序遍历
- 中序遍历
- 后序遍历
- 层序遍历
preOrderTraverse() 先序遍历
preOrderTraverse():()
,通过先序遍历方式遍历所有节点。
思路图解:
- 1、访问根节点
- 2、先序遍历其左子树
- 3、先序遍历其右子树
代码实现:递归法
代码实现:非递归法
inOrderTraverse() 中序遍历
inOrderTraverse():()
,通过中序遍历方式遍历所有节点。
遍历过程为:
- ①中序遍历其左子树;
- ②访问根节点;
- ③中序遍历其右子树。
代码实现:递归法
代码实现:非递归法
postOrderTraverse() 后序遍历
postOrderTraverse():()
,通过后序遍历方式遍历所有节点。
遍历过程为:
- ①后序遍历其左子树;
- ②后序遍历其右子树;
- ③访问根节点。
代码实现:递归法
代码实现:非递归法
levelOrderTraverse() 层序遍历
levelOrderTraverse():()
,通过层序遍历方式遍历所有节点。
遍历思路: 层序遍历很好理解,就是从上向下逐层遍历。层序遍历通常我们会借助队列来完成,也是队列的一个经典应用场景;
代码实现:非递归法
获取最值
getMinValue() 最小值
getMinValue():()
,返回树中最小的值。
在二叉搜索树中搜索最值是一件非常简单的事情,其实用眼睛看就可以看出来了。
图解:
代码实现:
getMaxValue() 最大值
getMaxValue():()
,返回树中最大的值。
代码实现:
search() 搜索
search():(value)
,在树中查找一个数据,如果节点存在,则返回true;如果不存在,则返回false。
二叉搜索树不仅仅获取最值效率非常高,搜索特定的值效率也非常高。
代码实现:递归法
代码实现:非递归法
remove() 删除@
remove():(value)
,从树中移除某个数据。
思路图解:
二叉搜索树的删除有些复杂,我们一点点完成。
1、查找要删除的节点。如果节点不存在则不需要删除。
2、找到后考虑三种情况:
- 情况一:该节点没有子节点(简单)
- 情况二:该节点有一个子节点(简单)
- 情况三:该节点有二个子节点(复杂)
3、情况一:该节点没有子节点(简单)
如果只有一个单独的根,直接删除该根
如果是叶节点,将该节点的父节点的left或right设为null
4、情况二:该节点有一个子节点(简单)
如果删除的是根节点,直接将其子节点赋值给root
修改该节点的父节点直接指向其子节点
5、情况三:该节点有二个子节点(复杂)
情况1:删除9节点,用9节点的某个后代(8或10)替代9的位置
情况2:删除7节点,用7节点的某个后代(5或8)替代7的位置,注意: 不是5或9
情况3:删除15节点,用15的某个后代(14或18)替代15的位置
规律:被删除节点的后代替代节点,可以是:
- 去左边找一个比删除节点小一些的节点,但是是左子树的最大节点,该点称为 前驱节点。
- 去右边找一个比删除节点大一些的节点,但是是右子树的最小节点,该点称为 后继节点。
搜索节点和父节点
重构:搜索节点
remove()和之前的search()方法的代码结构类似,可以合并
1、在search()方法中调用私有方法searchNode()
2、在remove()方法中调用私有方法searchNode()
3、实现抽取的搜索方法searchNode()
4、优化: 同时获取parent节点
为TreeNode节点添加
parent
属性在searchNode()方法中为parent赋值
在remove()方法中可以通过current获取其父节点
5、优化: 判断当前节点在其父节点的左边还是右边
为TreeNode节点添加
get isLeft()
和get isRight()
方法在remove()方法中调用判断当前节点在其父节点的左边还是右边
情况一:叶节点
如果只有一个单独的根,直接删除该根
如果是叶节点,将该节点的父节点的left或right设为null
情况二:一个子节点
如果删除的是根节点,直接将其子节点赋值给root
修改该节点的父节点直接指向其子节点
情况三:两个子节点
情况1:删除9节点,用9节点的某个后代(8或10)替代9的位置
情况2:删除7节点,用7节点的某个后代(5或8)替代7的位置,注意: 不是5或9
情况3:删除15节点,用15的某个后代(14或18)替代15的位置
规律:被删除节点的后代替代节点,可以是:
- 去左边找一个比删除节点小一些的节点,但是是左子树的最大节点,该点称为 前驱节点。
- 去右边找一个比删除节点大一些的节点,但是是右子树的最小节点,该点称为 后继节点。
1、实现获取后继节点的方法getSuccessor()
2、用后继节点替换被删除的节点
重构:删除节点
插入对象@
我们不但可以在二叉搜索树中存放对象数字,还可以存放对象等类型的数据。
1、创建一个Product产品类
2、通过Product创建对象实例,并插入到bst树中
3、问题:此时插入的对象是没有经过比较的,形成了链结构。
解决思路:可以通过在Product类中,重写valueOf()方法,让对象可以比较price
属性。
4、优化:打印树时,显示更多信息。
思路:
- 导入 hy-algokit 包中的接口 PrintableNode 并implements实现它,以此约束Node类的属性。
- 在自定义的
get value()
方法中,返回想要打印的信息。
总结
看到这里,你就会发现删除节点相当棘手。
实际上,因为它非常复杂,一些程序员都尝试着避开删除操作,因此就出现了其他删除节点的思路:
思路一:
- 1、在Node类中添加一个isDeleted字段。
- 2、删除某节点时,就设置
isDeleted = true
。 - 3、在查找操作时,先判断节点的isDeleted值。
- 优点:实现起来比较简单,每次删除节点不会改变原有的树结构。
- 缺点:在存储中依然保留着被删除的节点。
思路二:
- 1、只修改被删除节点的value值为后继节点的值:
delNode.value = successor.value
- 2、后继节点后的节点结构依然调整。
二叉搜索树的缺陷
优势:二叉搜索树作为数据存储的结构有重要的优势:
可以快速地找到给定关键字的数据项 并且可以快速地插入和删除数据项。
问题: 二叉搜索树有一个很麻烦的问题:插入有序数据 会形成 非平衡树。
如果插入的数据是有序的数据,比如下面的情况:有一棵初始化为 9 8 12 的二叉树,插入下面的数据:7 6 5 4 3
非平衡树(Unbalanced Tree) 指在树的数据结构中,节点的左右子树的高度差异较大,导致树形结构不平衡,可能会影响其性能表现,特别是在查找、插入和删除操作时。查找效率由O(logN)
变成了O(N)
。
平衡树
为了能以较快的时间O(logN)来操作一棵树,我们需要保证树总是平衡的:
- 至少大部分是平衡的,那么时间复杂度也是接近O(logN)的
- 也就是说树中每个节点左边的子孙节点的个数,应该尽可能的等于右边的子孙节点的个数。
常见的平衡树:
AVL树:是最早的一种平衡树,它通过在每个节点多存储一个额外的数据保持树的平衡。时间复杂度为O(logN)。
红黑树:也通过一些特性来保持树的平衡。时间复杂度为O(logN)。
对比: 插入/删除等操作,红黑树性能优于AVL树。红黑树因此应用更多。
平衡二叉树
平衡树 Balanced Tree
平衡树(Balanced Tree) 是一种特殊的二叉搜索树,其目的是通过一些特殊的技巧来维护树的高度平衡。从而保证树的搜索、插入、删除等操作的时间复杂度都较低。
为什么需要平衡树:
一棵树如果退化成链状结构,那么搜索、插入、删除等操作的时间复杂度就会达到最坏情况,即O(n)。
平衡树通过不断调整树的结构,使得树的高度尽量平衡,从而保证搜索、插入、删除等操作的时间复杂度都较低,通常为O(logn)。因此,如果我们需要高效地处理大量的数据,那么平衡树就显得非常重要了。
应用: 平衡树的应用非常广泛,如索引、内存管理、图形学等领域均有广泛使用。
插入有序数字导致树不平衡:
比如我们连续的插入1、2、3、4、5、6
的数字,那么前面的二叉搜索树最终形成的结构如下
删除元素导致树不平衡:
事实上不只是添加会导致树的不平衡,删除元素也可能会导致树的不平衡。
平衡树的方式
方式一:限制插入、删除节点。在树特性的状态下,不允许插入或者删除某些节点,不现实。
方式二:使用特定方式再平衡树。在随机插入或者删除元素后,通过某种方式观察树是否平衡,如果不平衡通过特定的方式(比如旋转),让树保持平衡。
常见的平衡二叉搜索树
常见的平衡二叉搜索树有哪些呢?
- AVL树:这是一种最早的平衡二叉搜索树,在1962年由G.M. Adelson-Velsky和E.M. Landis发明。
- 红黑树:这是一种比较流行的平衡二叉搜索树,由R. Bayer在1972年发明。
- Splay树:这是一种动态平衡二叉搜索树,通过旋转操作对树进行平衡。
- Treap:这是一种随机化的平衡二叉搜索树,是二叉搜索树和堆的结合。
- B-树:这是一种适用于磁盘或其他外存存储设备的多路平衡查找树。
这些平衡二叉搜索树都用于保证搜索树的平衡,从而在插入、删除、查找操作时保证了较低的时间复杂度。
红黑树和AVL树是应用最广泛的平衡二叉搜索树:
- 红黑树:红黑树被广泛应用于实现诸如操作系统内核、数据库、编译器等软件中的数据结构,其原因在于它在插入、删除、查找操作时都具有较低的时间复杂度。
- AVL树:AVL树被用于实现各种需要高效查询的数据结构,如计算机图形学、数学计算和计算机科学研究中的一些特定算法。
AVL树
概述
AVL树(Adelson-Velsky and Landis Tree) 是一种自平衡的二叉搜索树,其特点是通过维护每个节点的平衡因子来保持树的平衡。插入或删除节点后,若树不平衡,需要通过旋转操作(单旋转或双旋转)来恢复平衡。
AVL树的平衡因子 是该节点左子树的高度减去右子树的高度,平衡因子的绝对值不能大于1。
- 在AVL树中,任意节点的平衡因子只有
1
或-1
或0
,因此AVL树也被称为高度平衡树。 - 对于每个节点,它的左子树和右子树的高度差不超过1。
- 这使得AVL树具有比普通的二叉搜索树更高的查询效率。
- 当插入或删除节点时,AVL树可以通过旋转操作来重新平衡树,从而保证其平衡性。
由于AVL树具有自平衡性,因此其最坏情况下的时间复杂度仅O(logn)。
旋转操作
AVL树的插入和删除操作与普通的二叉搜索树类似,但是在插入或者删除之后,需要继续保持树的平衡。
- AVL树需要通过旋转操作来维护平衡。
- 有四种情况旋转操作:左左情况、右右情况、左右情况和右左情况双旋。
- 具体使用哪一种旋转,要根据不同的情况来进行区分和判断。
AVL树的实现
手写实现AVL树本身的过程是相当的复杂的,所以对于它的学习路线我进行了专门的设计。
我们如何学习呢?
- 步骤一:学习AVL树节点的封装。
- 步骤二:学习AVL树的旋转情况下如何编写代码。
- 步骤三:写出不同情况下进行的不同旋转操作。
- 步骤四:写出插入操作后,树的再平衡操作。
- 步骤五:写出删除操作后,树的再平衡操作。
我们可以通过分治的思想,一步步实现上面的功能,再将功能组合在一起就完成了AVL树的编写过程。
节点的封装
封装-AVLTreeNode
封装-getHeight()
测试:
封装-getBalanceFactor()
测试:
封装-isBalanced
测试:
封装-higherChild
测试:
封装-旋转
封装-rightRotation()
实现步骤: 核心就是要修改变化节点的3个属性(指针):parent、left、right。
处理pivot节点:
- 1、
pivot = root.left
:选择当前节点的左子节点作为旋转轴心。 - 2、
pivot.parent = root.parent
:pivot的父节点指向root节点的父节点。
- 1、
处理pivot右子节点:
- 3、
root.left = pivot.right
:root节点的左节点,指向pivot的右节点。 - 4、
pivot.right.parent = root
:如果右节点有值, 那么右节点的父节点指向root节点。
- 3、
处理root节点:
- 5、
pivot.right = root
:pivot的右节点指向root。 - 6、
root.parent = pivot
:root节点的父节点指向pivot。
- 5、
挂载pivot节点:
- 7、
pivot.parent = null
:如果pivot没有父节点,avltree.root = pivot
。 - 8、
root.isLeft = true
:如果pivot是父节点的左子节点,pivot.parent.left = pivot
。 - 9、
root.isRight= true
:如果pivot是父节点的右子节点,pivot.parent.right = pivot
。
- 7、
图解:
代码实现:
测试:
封装-leftRotation()
实现步骤: 核心就是要修改变化节点的3个属性(指针):parent、left、right。
- 处理pivot节点:
root.right
- 1、
pivot = root.right
:选择当前节点的右子节点作为旋转轴心。 - 2、
pivot.parent = root.parent
:pivot的父节点指向root节点的父节点。
- 1、
- 处理pivot左子节点:
pivot.left
- 3、
root.right = pivot.left
:root节点的右子节点,指向pivot的左子节点。 - 4、
pivot.left.parent = root
:如果左子节点有值, 那么左子节点的父节点指向root节点。
- 3、
- 处理root节点:
root(this)
- 5、
pivot.left = root
:pivot的左子节点指向root。 - 6、
root.parent = pivot
:root节点的父节点指向pivot。
- 5、
- 挂载pivot节点:
pivot.parent
- 7、
pivot.parent = null
:如果pivot没有父节点,avltree.root = pivot
。 - 8、
root.isLeft = true
:如果pivot是父节点的左子节点,pivot.parent.left = pivot
。 - 9、
root.isRight= true
:如果pivot是父节点的右子节点,pivot.parent.right = pivot
。
- 7、
图解:
代码实现:
测试:
AVL树的封装
封装-AVLTree
测试:
封装-rebalance()
思路:旋转的四种情况分析
1、先找到失衡的节点:
- 失衡的节点称之为grandParent
- 失衡节点的儿子(更高的儿子)称之为parent
- 失衡节点的孙子(更高的孙子)称之为current
2、如果从grandParent到current的是:
- LL:左左情况,那么右旋转;
- RR:右右情况,那么左旋转;
- LR:左右情况,那么先对parent进行左旋转,再对grandParent进行右旋转;
- RL:右左情况,那么先对parent进行右旋转,再对grandParent进行左旋转;
图解:
代码实现:
封装-插入
图解:
封装-createNode()
需求:BSTree插入的节点类型 TreeNode
,而AVLTree在insert()方法中需要创建一个AVLTreeNode
。
思路:可以封装一个模板方法,让子类来进行重写即可。
代码实现:
1、在父类BSTree
中封装createNode()方法
封装createNode()方法
修改父类
BSTree
中的insert()方法
2、在子类AVLTree
中重写createNode()方法
重写createNode()方法
此时
AVLTree
类实例调用父类中的insert()方法时,在该方法中调用createNode()创建的就是一个AVLTreeNode
。
重构-insertNode()
需求:因为之后我们需要从当前节点中寻找parent节点,所以最好让每一个节点都保存一份parent节点。(之前代码是不需要的)
代码实现:
封装-checkBalance()
1、继续在父类BSTree
中使用之前的insert()方法,在插入完成后调用checkBalance()去检查树的平衡
2、在子类AVLTree
中重写父类的checkBalance()方法检查树的平衡
3、测试:随机一些数字,插入到AVLTree中来查看树是否平衡
封装-删除@
图解:
实现思路:
- 1、检查节点平衡:删除节点后,调用
checkBalance()
检查节点是否平衡。 - 2、修改删除方案:
- 之前的方案是找到删除节点后,使用后继节点替换它。该方案需要处理的指针较多。
- 现在的方案是找到删除节点后,将后继节点的value赋值给它。再删除后继节点。
- 3、寻找删除节点(检测平衡的节点):
- 情况一:删除节点本身是叶子节点:删除节点 = current节点。
- 情况二:删除节点只有一个子节点:删除节点 = current节点。
- 情况三:删除节点有两个子节点:删除节点 = 后继节点原来的位置。
检查节点平衡
删除节点后,调用checkBalance()
检查节点是否平衡
重构-remove()
checkBalance()中应该传入被删除的节点。但如果有两个子节点,找的就是前驱节点和后继节点,最终是将前驱和后继位置的节点删除掉。寻找的应该是从AVL树中被移除位置的节点。
情况一:删除节点本身是叶子节点:删除节点 = current节点。
情况二:删除节点只有一个子节点:删除节点 = current节点。
情况三:删除节点有两个子节点:删除节点 = 后继节点原来的位置。
关键点有两个:
- 必须要找到检测位置的节点
- 检测位置的节点必须有父节点
重构代码:使用第二种方案:找到删除节点后,将后继节点的value赋值给它。再删除后继节点。
1、重构-remove()方法
2、重构-getSuccessor()方法
3、随机插入和删除测试
我们可以随机一些数字,插入,再删除,AVLTree中来查看树是否平衡。
优化-rebalance()
执行rebalance()操作的节点:
- 插入节点的所有父节点:一直向上查找父节点。
- 删除节点的所有父节点:一直向上查找父节点。
继续rebalance()操作的前提: 取决于在插入或删除一个节点后后,是否改变了祖父节点的高度。
插入节点,再平衡rebalance后,高度不变,不需要继续后续节点的再平衡rebalance。
删除节点,再平衡rebalance后,高度改变了,需要继续后续节点的再平衡rebalance。
重构代码:
红黑树
概述
红黑树很难:红黑树是数据结构中很难的一个知识点,难到什么程度呢?
- 基本你跟别人聊数据结构的时候, 他不会和你聊红黑树, 因为它是数据结构中一个难点中的难点.
- 数据结构的学习本来就比较难了, 红黑树是又将难度上升一个档次的知识点.
面试段子:面试的时候经常出现这个场景:
- 面试官: 你知道红黑树吗?
- 面试者: 知道啊。
- 面试官: 知道原理吗?
- 面试者: 不知道啊。
- 面试官: 那你让‘不’过来面试我们公司吧,你先回去等通知吧。
面试:哪些面试会出现红黑树:
- 在面试时基本不会让手写红黑树,即使是面试Google、Apple这样的公司,也很少会出现。
- 通常是这样问题的(比如腾讯的一次面试题):为什么已经有平衡二叉树(比如AVL树)了,还需要红黑树。
红黑树
红黑树(Red-Black Tree) 是一种自平衡的二叉搜索树,它能够保证在最坏情况下,树的高度也不会超过 2 * log(n)
,从而使得基本的动态集合操作(如插入、删除、查找)能在对数时间内完成。
历史:
- 它在1972年由鲁道夫·贝尔发明,被称为“对称二叉B树”。
- 它现代的名字源于Leo J. Guibas和罗伯特·塞奇威克于1978年写的一篇论文。
特性:红黑树除了符合二叉搜索树的基本规则外, 还添加了一些特性:
- 1、每个节点都是红色或黑色。
- 2、根节点是黑色。
- 3、每个叶子节点(NIL节点)是黑色的空节点。叶子节点通常是指代
null
或者空指针的节点。- 因为在红黑树中,黑色节点的数量表示从根节点到叶子节点的黑色节点数量。
- 4、如果一个节点是红色的,那么它的子节点必须是黑色的。也就是说,红色节点不能有两个连续的红色节点。
- 它保证了红色节点的颜色不会影响树的平衡,同时保证了红色节点的出现不会导致连续的红色节点。
- 5、从任何一个节点到其所有后代叶子节点的路径上,经过的黑色节点数目相同。这意味着从根到每个叶子的路径上黑色节点的数量是相同的。
- 它是最重要的性质,保证了红黑树的平衡性。
红黑树的相对平衡
前面的性质约束确保红黑树的关键特性:从根到叶子的最长可能路径, 不会超过最短可能路径的两倍长。结果就是这个树保持基本平衡。虽然没有做到绝对的平衡,但是可以保证在最坏的情况下依然是高效的。
如何确保最长路径不超过最短路径的两倍:
- 性质五:决定了最短路径和最长路径必须有相同的黑色节点。
- 最短路径:全部是黑色节点,黑色节点数量为
n
。 - 最长路径:首尾是黑色节点,黑色节点数量为
n
;中间全部是红色节点,红色节点数量为n – 1
。- 性质二:根节点是黑节点。
- 性质三:叶子节点都是黑节。
- 性质四:两个红色节点不能相连。
- 最短路径边的数量:
n – 1
。 - 最长路径边的数量:
(n + n – 1) - 1 = 2n – 2
。 - 最长路径一定不超过最短路径的2倍:
2n-2 = 2 * (n-1)
。
对比AVL树的性能
性能对比:
AVL树是一种平衡度更高的二叉搜索树,所以在搜索效率上会更高。但AVL树为了维护这种平衡性,在插入和删除操作时,通常会进行更多的旋转操作,所以效率相对红黑树较低。它们的搜索、添加、删除时间复杂度都是O(logn),但是细节上会有一些差异。
插入:AVL树 < 红黑树
- 插入红黑树:向红黑树插入30,会被插入到27的右子节点,并且30是红色节点时,依然符合红黑树的性质。对于红黑树来说,它不需要进行任何平衡操作。
- 插入AVL树:向AVL树插入30,也会被插入到27的右子节点,此时17节点会失衡。必须对17、25、27节点进行左旋转操作。
删除:AVL树 < 红黑树
搜索:红黑树 < AVL树
- 红黑树的高度比AVL树要高,同样是搜索30,红黑树需要搜索4次,AVL树搜索3次。
总结:红黑树相当于牺牲了一点点的搜索性能,来提高了插入和删除的性能。
开发中如何选择:
选择AVL树还是红黑树,取决于具体的应用需求。
如果需要保证每个节点的高度尽可能地平衡,可以选择AVL树。
如果需要保证删除操作的效率,可以选择红黑树。
在早期的时候,很多场景会选择AVL树,目前选择红黑树的越来越多(AVL树依然是一种重要的平衡树)。
比如操作系统内核中的内存管理;
比如Java的TreeMap、TreeSet底层的源码;
红黑树的实现
实现步骤分析
实现一个 TypeScript 红黑树的详细步骤:
- 定义红黑树的节点:定义一个带有键、值、颜色、左子节点、右子节点和父节点的类;
- 实现左旋操作:将一个节点向左旋转,保持红黑树的性质;
- 实现右旋操作:将一个节点向右旋转,保持红黑树的性质;
- 实现插入操作:在红黑树中插入一个新的节点,并保持红黑树的性质;
- 实现删除操作:从红黑树中删除一个节点,并保持红黑树的性质;
- 实现修复红黑树性质:在插入或删除操作后,通过旋转和变色来修复红黑树的性质;
- 其他方法较为简单,可以自行实现;
RedBlackNode
使用 TypeScript 的泛型编写红黑树的节点。
enum Color {
RED,
BLACK,
}
class RedBlackNode<T> {
value: T;
color: Color;
parent: RedBlackNode<T> | null;
left: RedBlackNode<T> | null;
right: RedBlackNode<T> | null;
constructor(
value: T,
color: Color = Color.RED,
parent: RedBlackNode<T> | null = null,
left: RedBlackNode<T> | null = null,
right: RedBlackNode<T> | null = null
) {
this.value = value;
this.color = color;
this.parent = parent;
this.left = left;
this.right = right;
}
}
RedBlackTree
红黑树的整体结构:
class RedBlackTree<T> {
root: RedBlackNode<T> | null = null;
// 查找某个节点再红黑树中的最小值
minimum(node: RedBlackNode<T> | null = this.root): RedBlackNode<T> | null {
let current = node;
while (current && current.left) {
current = current.left;
}
return current;
}
// 查找红黑树中的某个节点
private search(value: T): RedBlackNode<T> | null {
let node = this.root;
let parent: RedBlackNode<T> | null = null;
while (node) {
if (node.value === value) {
node.parent = parent;
return node;
}
parent = node;
if (value < node.value) {
node = node.left;
} else {
node = node.right;
}
}
return null;
}
}
export {};
旋转操作
实现左旋转和有旋转操作:
/**
* 左旋操作
*
* @param node 要进行左旋的结点
*/
private leftRotate(node: RedBlackNode<T>) {
// 获取 node 的右子节点
let rightChild = node.right!;
// 将右子节点的左子节点赋值给 node 的右子节点
node.right = rightChild.left;
// 如果右子节点的左子节点不为空,则将右子节点的左子节点的父节点指向 node
if (rightChild.left) {
rightChild.left.parent = node;
}
// 将右子节点的父节点指向 node 的父节点
rightChild.parent = node.parent;
// 如果 node 的父节点为空,则将右子节点设为根结点
if (!node.parent) {
this.root = rightChild;
}
// 如果 node 是它父节点的左子节点,则将右子节点设为 node 父节点的左子节点
else if (node === node.parent.left) {
node.parent.left = rightChild;
}
// 否则,将右子节点设为 node 父节点的右子节点
else {
node.parent.right = rightChild;
}
// 将 node 的父节点指向 rightChild,并将 rightChild 的左子节点指向 node
rightChild.left = node;
node.parent = rightChild;
}
/**
* 右旋转
* @param node 旋转节点
*/
private rightRotate(node: RedBlackNode<T>) {
// 获取旋转节点的左子节点
let leftChild = node.left!;
// 将旋转节点的左子节点的右子节点,接到旋转节点的左边
node.left = leftChild.right;
// 如果左子节点的右子节点不为空,设置它的父节点为旋转节点
if (leftChild.right) {
leftChild.right.parent = node;
}
// 将左子节点的父节点设为旋转节点的父节点
leftChild.parent = node.parent;
// 如果旋转节点的父节点不存在,说明左子节点变成根节点
if (!node.parent) {
this.root = leftChild;
} else if (node === node.parent.right) {
// 如果旋转节点是它父节点的右子节点,将父节点的右子节点设为左子节点
node.parent.right = leftChild;
} else {
// 如果旋转节点是它父节点的左子节点,将父节点的左子节点设为左子节点
node.parent.left = leftChild;
}
// 将旋转节点设为左子节点的右子节点
leftChild.right = node;
// 将旋转节点的父节点设为左子节点
node.parent = leftChild;
}
插入操作
实现插入操作,并且插入后实现红黑树的平衡和保持性质:
insert(value: T) {
// 创建一个新节点
let newNode = new RedBlackNode(value);
// 如果红黑树为空,将该节点作为根节点
if (!this.root) {
this.root = newNode;
// 根节点为黑色
newNode.color = Color.BLACK;
return;
}
// 初始化搜索变量current和parent
let current: RedBlackNode<T> | null = this.root;
let parent: RedBlackNode<T> | null = null;
// 搜索合适的插入位置
while (current) {
parent = current;
// 如果value小于当前节点,则继续往左子树搜索
if (value < current.value) {
current = current.left;
// 否则继续往右子树搜索
} else {
current = current.right;
}
}
// 将新节点的父节点设置为搜索到的父节点
newNode.parent = parent;
// 将新节点插入到合适的位置
if (value < parent!.value) {
parent!.left = newNode;
} else {
parent!.right = newNode;
}
// 修复插入导致的红黑树性质破坏
this.fixInsertion(newNode);
}
private fixInsertion(node: RedBlackNode<T>) {
// 当父节点存在且颜色为红时
while (node.parent && node.parent.color === Color.RED) {
// 获取祖父节点
let grandParent = node.parent.parent!;
// 父节点是祖父节点的左子节点
if (node.parent === grandParent.left) {
// 获取叔叔节点
let uncle = grandParent.right;
// 叔叔节点存在且颜色为红
if (uncle && uncle.color === Color.RED) {
// 将父节点颜色改为黑,叔叔节点颜色改为黑,祖父节点颜色改为红,node节点变为祖父节点,继续循环
node.parent.color = Color.BLACK;
uncle.color = Color.BLACK;
grandParent.color = Color.RED;
node = grandParent;
} else {
// 当前节点是父节点的右子节点
if (node === node.parent.right) {
// 将当前节点变为父节点,进行左旋操作
node = node.parent;
this.leftRotate(node);
}
// 将父节点颜色改为黑,祖父节点颜色改为红,进行右旋操作
node.parent!.color = Color.BLACK;
grandParent.color = Color.RED;
this.rightRotate(grandParent);
}
} else {
// 父节点是祖父节点的右子节点,与上面的同理
let uncle = grandParent.left;
// 如果叔叔节点是红色的
if (uncle && uncle.color === Color.RED) {
// 父节点设置为黑色
node.parent.color = Color.BLACK;
// 叔叔节点设置为黑色
uncle.color = Color.BLACK;
// 祖父节点设置为红色
grandParent.color = Color.RED;
// 将当前节点设置为祖父节点
node = grandParent;
} else {
// 如果当前节点是父节点的左节点
if (node === node.parent.left) {
// 将当前节点设置为父节点
node = node.parent;
// 右旋父节点
this.rightRotate(node);
}
// 父节点设置为黑色
node.parent!.color = Color.BLACK;
// 祖父节点设置为红色
grandParent.color = Color.RED;
// 左旋祖父节点
this.leftRotate(grandParent);
}
}
}
// 根节点设置为黑色节点
this.root!.color = Color.BLACK;
}
删除操作
红黑树的删除操作和删除后的再平衡
/**
* 删除红黑树中的某个节点
*
* @param value 要删除的节点的值
*/
delete(value: T) {
// 先找到要删除的节点
const nodeToDelete = this.search(value);
// 如果不存在,就直接退出
if (!nodeToDelete) {
return;
}
// 否则删除节点
this._delete(nodeToDelete);
}
/**
* 删除红黑树中的节点
* @param node 要删除的节点
*/
private _delete(node: RedBlackNode<T>) {
// 如果该节点同时存在左右节点,则找到右子树的最小节点作为该节点的后继
if (node.left && node.right) {
const successor = this.minimum(node.right);
node.value = successor!.value;
node = successor!;
}
let child: RedBlackNode<T> | null;
// 如果该节点存在左节点,则将该左节点作为它的唯一子节点
if (node.left) {
child = node.left;
} else if (node.right) {
// 如果该节点存在右节点,则将该右节点作为它的唯一子节点
child = node.right;
} else {
child = null;
}
// 如果该节点没有子节点,直接删除
if (!child) {
// 如果该节点是黑色,则需要特殊处理
if (node.color === Color.BLACK) {
this._deleteCase1(node);
}
this._removeNode(node);
} else {
// 如果该节点是黑色,则需要特殊处理
if (node.color === Color.BLACK) {
// 如果该节点的唯一子节点是红色,则将该唯一子节点设置为黑色
if (child.color === Color.RED) {
child.color = Color.BLACK;
} else {
this._deleteCase1(node);
}
}
// 用该节点的唯一子节点替换该节点
this._replaceNode(node, child);
}
}
private _deleteCase1(node: RedBlackNode<T>) {
// 如果有父节点,就进入 Case 2
if (node.parent) {
this._deleteCase2(node);
}
}
private _deleteCase2(node: RedBlackNode<T>) {
// 找到兄弟节点
const sibling = this._sibling(node);
// 如果兄弟节点存在且颜色为红色
if (sibling && sibling.color === Color.RED) {
// 父节点颜色变为红色
node.parent!.color = Color.RED;
// 兄弟节点颜色变为黑色
sibling.color = Color.BLACK;
// 如果删除的节点是左子节点
if (node === node.parent!.left) {
// 则向左旋转
this.leftRotate(node.parent!);
} else {
// 否则向右旋转
this.rightRotate(node.parent!);
}
}
this._deleteCase3(node);
}
private _deleteCase3(node: RedBlackNode<T>) {
const sibling = this._sibling(node);
// 当父节点颜色是黑色,兄弟节点颜色是黑色,兄弟节点的左右子节点都是黑色
if (
node.parent!.color === Color.BLACK &&
sibling &&
sibling.color === Color.BLACK &&
(!sibling.left || sibling.left.color === Color.BLACK) &&
(!sibling.right || sibling.right.color === Color.BLACK)
) {
// 将兄弟节点颜色设置为红色
sibling.color = Color.RED;
// 递归处理父节点
this._deleteCase1(node.parent!);
} else {
// 进入下一个情况
this._deleteCase4(node);
}
}
private _deleteCase4(node: RedBlackNode<T>) {
const sibling = this._sibling(node);
// 当父节点为红色,兄弟节点为黑色,且兄弟节点的左右子树为黑色时
if (
node.parent!.color === Color.RED &&
sibling &&
sibling.color === Color.BLACK &&
(!sibling.left || sibling.left.color === Color.BLACK) &&
(!sibling.right || sibling.right.color === Color.BLACK)
) {
// 将兄弟节点涂红色
sibling.color = Color.RED;
// 父节点涂黑色
node.parent!.color = Color.BLACK;
} else {
// 否则进入下一个删除 case
this._deleteCase5(node);
}
}
private _deleteCase5(node: RedBlackNode<T>) {
const sibling = this._sibling(node);
if (sibling && sibling.color === Color.BLACK) {
// 如果当前节点是它父的左节点,并且兄弟节点的右节点存在且为红色
if (
node === node.parent!.left &&
sibling.right &&
sibling.right.color === Color.RED
) {
// 将兄弟节点的颜色设置为红色
sibling.color = Color.RED;
// 兄弟节点的右节点设置为黑色
sibling.right!.color = Color.BLACK;
// 对兄弟节点进行左旋
this.leftRotate(sibling);
} else if (
node === node.parent!.right &&
sibling.left &&
sibling.left.color === Color.RED
) {
// 同上
sibling.color = Color.RED;
sibling.left!.color = Color.BLACK;
this.rightRotate(sibling);
}
}
this._deleteCase6(node);
}
private _deleteCase6(node: RedBlackNode<T>) {
const sibling = this._sibling(node);
// 将兄弟节点颜色设置成父节点颜色
sibling!.color = node.parent!.color;
// 将父节点颜色设置成黑色
node.parent!.color = Color.BLACK;
if (node === node.parent!.left) {
// 将兄弟节点的右子节点颜色设置成黑色
sibling!.right!.color = Color.BLACK;
// 对父节点左旋
this.leftRotate(node.parent!);
} else {
// 将兄弟节点的左子节点颜色设置成黑色
sibling!.left!.color = Color.BLACK;
// 对父节点右旋
this.rightRotate(node.parent!);
}
}
private _removeNode(node: RedBlackNode<T>) {
if (!node.parent) {
this.root = null;
} else if (node === node.parent.left) {
node.parent.left = null;
} else {
node.parent.right = null;
}
}
private _replaceNode(oldNode: RedBlackNode<T>, newNode: RedBlackNode<T>) {
if (!oldNode.parent) {
this.root = newNode;
} else if (oldNode === oldNode.parent.left) {
oldNode.parent.left = newNode;
} else {
oldNode.parent.right = newNode;
}
newNode.parent = oldNode.parent;
}
private _sibling(node: RedBlackNode<T>) {
if (!node.parent) {
return null;
}
return node === node.parent.left ? node.parent.right : node.parent.left;
}